进入内核前的苦力活[linux源码趣读]

总体

image-20240413113503285

加载代码

  • pc指针初始指向0xFFFF0(ROM) 代表BIOS的地址
  • 加载硬盘第一扇区代码(bootsetct)到0x7c00
  • 复制到0x90000
  • 跳转到go代码,设置好cs ds ss sp
  • 把全部os代码搬入内存(setup.s 2~5; head.s 240扇区),至此bootsect.s使命完成
  • setup.s 使用int 指令初始化光标、内存、显卡、磁盘等信息放到bootsect.s的位置,并把system代码复制到0位置

image-20240413122232858

进入保护模式

setup.s

  1. 设置初始化IDT(键盘、时钟、串口、鼠标),GDT(包含代码段、数据段描述符)并设置寄存器指向他们的地址

  2. 将cr0中PE置为1,切换到保护模式(地址转换也响应变换)

    • 原来实模式下地址转换的方法image-20240413114140760

    • 保护模式下转换方法:需要转换一下段基址(段寄存器(比如 ds、ss、cs)里存储的是段选择子,段选择子去全局描述符表中寻找段描述符,从中取出段基址

      image-20240413114229120

  3. 跳转到cs:ip 0:0位置,从现在开始进入system代码

  4. 重新设置idt、gdt,指向新的空间

image-20240413113535007

开启分页

地址转换

cr0中的PG设置为1

没有开启分页机制的时候,只需要经过这一步转换即可得到最终的物理地址了,但是在开启了分页机制后,又会多一步转换

image-20240413115624426

二级页表线性地址转物理地址(第一级叫页目录表 PDE,第二级叫页表 PTE),MMU负责转换

0000000011_0100000000_000000000000

image-20240413115734998

当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF

而按照当前的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址),因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定。

4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB

全局结构

image-20240413120123983

CR3寄存器是虚拟内存管理的核心部分,与操作系统的内存管理紧密相关。当操作系统需要切换当前的内存映射时(如进程切换时),它会更新CR3寄存器的值来指向新的页目录表。

小结

Intel 体系结构的内存管理可以分成两大部分,也就是标题中的两板斧,分段分页

分段机制在之前几回已经讨论过多次了,其目的是为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。(保护模式必须开启)

分页机制是本回讲的内容,开机后分页机制默认是关闭状态,需要我们手动开启,并且设置好页目录表(PDE)和页表(PTE)。其目的在于可以按需使用物理内存,同时也可以在多任务时起到隔离的作用,这个在后面将多任务时将会有所体会。

  • 逻辑地址:我们程序员写代码时给出的地址叫逻辑地址,其中包含段选择子和偏移地址两部分。
  • 线性地址:通过分段机制,将逻辑地址转换后的地址,叫做线性地址。而这个线性地址是有个范围的,这个范围就叫做线性地址空间,32 位模式下,线性地址空间就是 4G。
  • 物理地址:就是真正在内存中的地址,它也是有范围的,叫做物理地址空间。那这个范围的大小,就取决于你的内存有多大了。

最后进入main函数是利用ret指令,ret会将栈顶作为下一条指令的地址,所以只需要将main函数提前入栈即可

总结

前五节:载入代码

image-20240413122908367

之后包含进入保护模式(分段)以及开启分页,初始化了IDT、GDT、页表,并且设置响应寄存器指向他们:idtr 寄存器指向了 idt,这个就是中断的设置;gdtr 寄存器指向了 gdt,这个就是全局描述符表的设置,可以简单理解为分段机制的设置;cr3 寄存器指向了页目录表的位置

image-20240415111114132

Intel 本身对于访问内存就分成三类:

  • 代码
  • 数据

而 Intel 也提供了三个段寄存器来分别对应着三类内存:

  • 代码段寄存器(cs)
  • 数据段寄存器(ds)
  • 栈段寄存器(ss)

具体来说:

  • cs:eip 表示了我们要执行哪里的代码。
  • ds:xxx 表示了我们要访问哪里的数据。
  • ss:esp 表示了我们的栈顶地址在哪里。

而第一部分的代码,也做了如下工作:

  • ds 设置为了 0x10,表示指向了索引值为 2 的全局描述符,即数据段描述符。
  • cs 通过一次长跳转指令设置为了 8,表示指向了索引值为 1 的全局描述符,即代码段描述符。
  • ss:esp 这个栈顶地址设置为 user_stack 数组的末端。

你看,分段和分页,以及这几个寄存器的设置,其实本质上就是安排我们今后访问内存的方式,做了一个初步规划,包括去哪找代码、去哪找数据、去哪找栈,以及如何通过分段和分页机制将逻辑地址转换为最终的物理地址

而所有上面说的这一切,和 Intel CPU 这个硬件打交道比较多,设置了一些最最最最基础的环境和内存布局,为之后进入 main 函数做了充分的准备,因为 c 语言虽然很底层了,但也有其不擅长的事情,就交给第一部分的汇编语言来做,所以我称第一部分为进入内核前的苦力活

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void main(void) {
ROOT_DEV = ORIG_ROOT_DEV;
drive_info = DRIVE_INFO;
memory_end = (1<<20) + (EXT_MEM_K<<10);
memory_end &= 0xfffff000;
if (memory_end > 16*1024*1024)
memory_end = 16*1024*1024;
if (memory_end > 12*1024*1024)
buffer_memory_end = 4*1024*1024;
else if (memory_end > 6*1024*1024)
buffer_memory_end = 2*1024*1024;
else
buffer_memory_end = 1*1024*1024;
main_memory_start = buffer_memory_end;
mem_init(main_memory_start,memory_end);
trap_init();
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init();
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();
move_to_user_mode();
if (!fork()) {
init();
}
for(;;) pause();
}